코드 리뷰
지하철 미션 1단계
-
default 값이 있거나 null(빈 값)이 들어올 수 있는 값의 경우, 해당 값이 없는 경우에 대한 생성자를 선언해줘도 좋다.
//LineService.java LineEntity newLine = new LineEntity(request.getName(), request.getColor(), null);
//LineService.java LineEntity newLine = new LineEntity(request.getName(), request.getColor()); ... //LineEntity.java public LineEntity(String name, String color, int extraCharge) { this(0L, name, color, extraCharge,null); }
-
api의 URI를 지을 때는 리소스 간의 관계가 잘 드러나도록 순서를 고려해주어야 한다.
-
필드들을 일일이 추출하여 비교하거나 equals를 따로 구현해주지 않고도 usingRecursiveComparison()을 사용해 객체의 필드 전체를 비교할 수 있다.
- id처럼 비교 대상에서 제외하고 싶은 필드의 경우
ignoringFields()
를 사용하면 된다.
LineResponse expected=new LineResponse(1L, "1호선","파란색",0); LineResponse actual = lineService.findLineResponseById(lineId); Assertions.assertThat(actual).usingRecursiveComparison().isEqualTo(expected);
- id처럼 비교 대상에서 제외하고 싶은 필드의 경우
지하철 2단계(1)
-
조회 쿼리의 경우 Transactional을 적용해줄 필요가 없다.
-
트랜잭션을 적용해야 하는 기준에 대해 생각해보자.
-
+) Transactional은 Dao, Repository, Service 중 어디에 붙이는 게 좋을까?
=>Service
Where should "@Transactional" be placed Service Layer or DAO
-
클래스에
@Transactional
을 적용해줬을 때, 특정 메서드만 해당 설정을 제외시키고 싶다면@Transactional(propagation = Propagation.NOT_SUPPORTED)
를 붙여주면 된다.@Transactional public class MyTransactionalClass { @Transactional(propagation = Propagation.NOT_SUPPORTED) public void nonTransactionalMethod() {...} }
-
-
체이닝이 길어지면 가독성이 떨어질 수 있으니, 아래와 같은 경우 더해주는 값을 변수로 분리 시켜주는 것이 좋다.
charge .add(calculateOverCharge(SECOND_CHARGE_BOUND.substract(FIRST_CHARGE_BOUND), DISTANCE_FIVE_UNIT)) .add(calculateOverCharge(distance.substract(SECOND_CHARGE_BOUND), DISTANCE_EIGHT_UNIT));
final Charge tenToFiftyExtraCharge = calculateOverCharge( SECOND_CHARGE_BOUND.substract(FIRST_CHARGE_BOUND), DISTANCE_FIVE_UNIT); final Charge overFiftyExtraCharge = calculateOverCharge( distance.substract(SECOND_CHARGE_BOUND), DISTANCE_EIGHT_UNIT); return charge.add(tenToFiftyExtraCharge).add(overFiftyExtraCharge);
-
계산 로직이 복잡한 경우, 경곗값 테스트를 해주면 좋다.
-
핸들러에서 예외의 최상위 구현체인 Exception.class를 잡게 하면 안된다.
-
의도하지 못한 예외까지 전부 잡혀서 장애가 발생했을 때 디버깅하기 어려워진다.
⇒특정 예외를 핸들링하게 해야 함.
-
의도한 예외에 대해서는 정해진 응답 코드를 내려주고, 의도하지 못한 예외는 500으로 보내 장애 상황을 파악할 수 있게 하는 것이 일반적이다.
-
의도한 예외도 전부 400으로 보내기보다 어떤 예외인지에 따라 400번대에서 응답코드를 정의해줄 수 있다.
-
-
uri를 설계할 때 리소스 간의 관계가 드러나도록 '순서'를 잘 배치하는 것이 좋다.
→관계가 있는 하위 리소스를 뒤에 배치
- 특정 노선에 해당하는 역의 목록을 불러오는 api의 경우,
/stations/lines/{lineId}
보단/lines/{lineId}/stations
이 관계가 더 잘 드러난다.
- 특정 노선에 해당하는 역의 목록을 불러오는 api의 경우,
-
만약 존재하지 않은 객체에 대해 update 쿼리를 날려도(이미 삭제한 데이터에 대해 한 번 더 삭제 요청을 보내거나 존재하지 않는 id를 담아 요청을 보내는 등) sql 상 오류는 아니기 때문에 예외가 발생하지 않는데, 서비스 계층에서 이에 대해 확인할 수 있도록 설계하는 것이 좋다.
- ex) dao, service 메서드에서 update의 결과(=변경된 row의 개수)를 반환하게 한다던지
자잘한 기술부채
✅returnAnswer(Answer)
public interface Answer<T>
임의의 응답을 구성하는 데 사용되는 인터페이스. mock 객체와 상호작용할 때 실행되는 동작과 반환 값을 지정할 수 있다.
when(mock.someMethod(anyString())).thenAnswer(
new Answer() {
public Object answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();
Object mock = invocation.getMock();
return "called with arguments: " + Arrays.toString(args);
}
});
//Following prints "called with arguments: [foo]"
System.out.println(mock.someMethod("foo"));
위와 같이 Answer 인터페이스를 구현하여 thenAnswer() 메서드의 인자로 넣어준다고 하자. mock
에 대해 임의의 문자열 인자를 가지고 someMethod()
메서드가 호출되면 answer 메서드가 호출된다.
invocation.getArguments()
를 통해 입력받은 인자를 불러올 수 있으며,invocation.getMock()
을 통해 mock 객체 자체를 불러올 수도 있다.
결과적으로 mock.someMethod(”foo”)는 **“called with arguments: foo”**를 반환한다.
✅LocalDate, LocalTime
각각 날짜, 시간을 표현하는 데 사용되는 클래스.
java.time 패키지에 포함된 대부분의 클래스는 이 두 클래스의 확장체인 경우가 많다.
-
객체 생성하기
객체 생성을 위한 정적 팩토리 메서드로
now()
,of()
를 제공한다.-
now()
: 현재의 날짜와 시간을 이용해 새로운 객체를 생성하여 반환한다.LocalDate today = LocalDate.now(); LocalTime present = LocalTime.now(); System.out.println(today + " " + present); //2023-05-22 21:37:50.634
-
of()
: 전달된 인수를 가지고 특정 날짜와 시간을 표현하는 새로운 객체를 생성하여 반환한다.// static LocalDate of(int year, int month, int dayOfMonth) LocalDate birthDay = LocalDate.of(1982, 02, 19); // static LocalTime of(int hour, int minute, int second, int nanoOfSecond) LocalTime birthTime = LocalTime.of(02, 02, 00, 100000000); System.out.println(birthDay + " " + birthTime); //1982-02-19 02:02:00.100
-
-
객체 접근하기
LocalDate와 LocalTime 클래스는 다양한 getter 메서드를 제공하고 있다.
-
LocalDate
메서드 설명 int get(TemporalField field)long getLong(TemporalField field) 해당 날짜 객체의 명시된 필드의 값을 int형이나 long형으로 반환함. int getYear() 해당 날짜 객체의 연도(YEAR) 필드의 값을 반환함. Month getMonth() 해당 날짜 객체의 월(MONTH_OF_YEAR) 필드의 값을 Month 열거체를 이용하여 반환함. int getMonthValue() 해당 날짜 객체의 월(MONTH_OF_YEAR) 필드의 값을 반환함. (1~12) int getDayOfMonth() 해당 날짜 객체의 일(DAY_OF_MONTH) 필드의 값을 반환함. (1~31) int getDayOfYear() 해당 날짜 객체의 일(DAY_OF_YEAR) 필드의 값을 반환함. (1~365, 윤년이면 366) DayOfWeek getDayOfWeek() 해당 날짜 객체의 요일(DAY_OF_WEEK) 필드의 값을 DayOfWeek 열거체를 이용하여 반환함. -
LocalTime
메서드 설명 int get(TemporalField field)long getLong(TemporalField field) 해당 시간 객체의 명시된 필드의 값을 int형이나 long형으로 반환함. int getHour() 해당 시간 객체의 시(HOUR_OF_DAY) 필드의 값을 반환함. int getMinute() 해당 시간 객체의 분(MINUTE_OF_HOUR) 필드의 값을 반환함. int getSecond() 해당 시간 객체의 초(SECOND_OF_MINUTE) 필드의 값을 반환함. int getNano() 해당 시간 객체의 나노초(NANO_OF_SECOND) 필드의 값을 반환함.
-
-
TemporalField 인터페이스
: 월(month), 시(hour)과 같이 날짜/시간에 관련된 필드를 정의해둔 인터페이스
- ChronoField 열거체에 정의된 열거체 상수
열거체 상수 설명 ERA 시대 YEAR 연도 MONTH_OF_YEAR 월 DAY_OF_MONTH 일 DAY_OF_WEEK 요일 (월요일:1, 화요일:2, ..., 일요일:7) AMPM_OF_DAY 오전/오후 HOUR_OF_DAY 시(0~23) CLOCK_HOUR_OF_DAY 시(1~24) HOUR_OF_AMPM 시(0~11) CLOCK_HOUR_OF_AMPM 시(1~12) MINUTE_OF_HOUR 분 SECOND_OF_MINUTE 초 DAY_OF_YEAR 해당 연도의 몇 번째 날 (1~365, 윤년이면 366) EPOCH_DAY EPOCH(1970년 1월 1일)을 기준으로 몇 번째 날
LocalTime present = LocalTime.now(); String ampm; if(present.get(ChronoField.AMPM_OF_DAY) == 0) { ampm = "오전"; } else { ampm = "오후"; } System.out.println("지금은 " + ampm + " " + present.get(ChronoField.HOUR_OF_AMPM) + "시입니다."); // 지금은 오전 9시입니다.
- ChronoField 열거체에 정의된 열거체 상수
-
비교 메서드
- isEqual() : (LocalDate에서만 제공) 날짜 비교
- isBefore() : 두 날짜/시간 객체를 비교하여 현재 객체가 명시된 객체보다 앞선 시간인지를 비교한다.
- isAfter() : 두 날짜/시간 객체를 비교하여 현재 객체가 명시된 객체보다 늦은 시간인지를 비교한다.
✅Profile
: 환경에 맞게 deploy하기
-
Profile 설정하기
-
방법1.
@Profile
어노테이션 사용하기@Configuration
클래스에@Profile
어노테이션을 붙여주면 Profile이 지정된다.-
production 환경에 대해 로컬 MySQL DB로 DataSource Bean 설정
@Profile("production") @Configuration public class ProductionDBConfig { @Bean public DataSource dataSource() { HikariDataSource dataSource = new HikariDataSource(); dataSource.setJdbcUrl("jdbc:mysql://localhost:13306/subway-path?serverTimezone=UTC&characterEncoding=UTF-8"); dataSource.setUsername("sa"); dataSource.setPassword("password"); return dataSource; } }
-
Test 환경에 대해 In-Memory DB로 DataSource Bean 설정
@Profile("test") @Configuration public class TestDBConfig { @Bean public DataSource dataSource() { HikariDataSource dataSource = new HikariDataSource(); dataSource.setJdbcUrl("jdbc:h2:mem:testdb;MODE=MySQL"); dataSource.setUsername("sa"); dataSource.setPassword("password"); return dataSource; } }
-
-
방법2. application-{profile name}.properties 파일을 생성하면 해당 파일이 환경 프로필의 설정 파일이 된다.
resources 경로에
application-prod.properties
,application-test.properties
파일들을 생성하면 ‘prod’, ‘test’ 프로필이 생성된다. 해당 properties 파일 내에 DB 설정을 작성해주면 된다.
-
-
생성한 프로필 활성화하기
: 위의 두 가지 방법으로 프로필을 생성하면, 프로필을 활성화해서 사용하면 된다.
-
프로덕션 코드의 경우
-
테스트 코드
-
@ActiveProfiles
어노테이션으로 프로필을 활성화할 수 있다.@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") public class ProfileTest { @Autowired private DataSource dataSource; @Test void profileTest() throws SQLException { System.out.println(dataSource.getConnection()); } }
-
-
✅@Deprecated
: Spring Boot에서 사용하지 않을 클래스/메서드를 지정해줄 때 사용하는 어노테이션이다.
리팩토링 과정에서 기존에 사용되고 있던 클래스와 메서드를 삭제하지 않고 사용금지할 때 사용한다.(바로 삭제했을 때 발생할 수 있는 사이드 이펙트를 방지하기 위해)
이 어노테이션이 붙은 클래스/메서드를 다른 클래스에서 사용하려 하면 코드에 경고가 뜬다.
public class Worker {
@Deprecated
public int calculate(Machine machine) {
return machine.exportVersions().size() * 10;
}
}
-
Javadoc 문법에도
@deprecated
태그가 존재한다. 같은 맥락으로 더이상 사용하지 않을 클래스/메서드에 달아준다. 일반적으로 사용되지 않는 이유를 함께 작성해준다. @link 태그를 함께 사용하여 대체 클래스/메서드를 지정해주기도 한다.public class Sample { /** * 사이즈 설정 * * @param width 폭 * @param height 높이 * @see Sample08_02#getWidth() * @see Sample08_02#getHeight() * @deprecated 다른 메소드로 대체되었다 {@link #setScale(int, int)} */ public void setSize(int width, int height) { ... } /** * 사이즈 설정 * * @param width 폭 * @param height 높이 */ public void setScale(int width, int height){ } }
[SpringBoot] @Deprecated 어노테이션으로 해당 클래스 및 메서드 은퇴시키기
✅@ManyToOne, @OneToMany
학생(Student)과 교실(ClassRoom)이 있다고 하고, 학생의 Primary Key를 교실 테이블이 Foreign Key로 가지고 있다.
이 경우, 교실은 여러 명의 학생을 가지고 있을 수 있고, 학생은 하나의 교실에만 소속될 수 있으니 두 테이블은 1:n 관계를 가진다.
이는 Student 입장에서 보면 OneToMany 관계이고, ClassRoom 입장에서 보면 ManyToOne 관계이다.
이를 Java Entity 클래스로 매핑해보자.
@ManyToOne
을 사용하는 경우
@Entity
@Table(name = "Student")
public class Student{
@Id
@GeneratedValue
@Column(name = "student_id")
private Long id;
@Column(name = "name")
private String className;
@ManyToOne
@JoinColumn(name = "class_id")
private ClassRoom classRoom;
...
}
@Entity
@Table(name = "ClassRoom")
public class ClassRoom {
@Id
@GeneratedValue
@Column(name = "class_id")
private Long id;
@Column(name = "name")
private String className;
...
}
이렇게 하면 Student 테이블을 조회할 때 class_id를 join key로 사용해 ClassRoom 테이블에 있는 교실 정보를 함께 불러온다.
-
양방향 매핑
-
Student Entity의 classRoom 필드가 관계의 주인이고, ClassRoom Entity에서 Student Entity 정보를 조회할 수 있도록 하는 양방향 매핑 코드를 작성해보자.
-
양방향 매핑을 위해 ClassRoom Entity에 다음 코드를 추가하면 된다.
- ClassRoom Entity는 다수의 Student Entity를 가질 수 있으므로 List
형으로 객체를 정의한다. - 교실 하나가 여러 명의 학생을 가질 수 있으므로 @OneToMany 어노테이션을 추가한다.
- 조회하려는 정보는 Student Entity의 classRoom 정보를 참고할 것이기 때문에 mappedBy를 통해 연관관계를 매핑한다.(이를 생략하면 로직 실행 시 중간 테이블이 생성된다.)
@Entity @Table(name = "ClassRoom") public class ClassRoom { @Id @GeneratedValue @Column(name = "class_id") private Long id; @Column(name = "name") private String className; @OneToMany(mappedBy = "classRoom") private List<Student> students = new ArrayList<>(); ... }
- ClassRoom Entity는 다수의 Student Entity를 가질 수 있으므로 List
-
@OneToMany
를 사용하는 경우
@Entity
@Table(name = "ClassRoom")
public class ClassRoom {
@Id
@GeneratedValue
@Column(name = "class_id")
private Long id;
@Column(name = "name")
private String className;
@OneToMany
@JoinColumn(name="student_id")
private List<Student> students = new ArrayList<>();
}
@Entity
@Table(name = "Student")
public class Student{
@Id
@GeneratedValue
@Column(name = "id")
private Long id;
@Column(name = "name")
private String studentName;
...
}
연관관계의 주인이 @ManyToOne가 되도록 하는 것이 의미가 명확하고 이해하기 쉽다.
[JPA] @ManyToOne, @OneToMany 이해하기
✅로컬에서 DB 셋팅하는 방법(mysql)
(MySQL을 실행시킬 수 있는 환경이 조성되어 있는 것을 전제한다.)
build.gradle에 다음 의존성을 추가해준다.
runtimeOnly 'mysql:mysql-connector-java:8.0.28'
application.properties를 다음과 같이 작성한다.
spring.datasource.url=jdbc:mysql://localhost:[포트(기본 3306)]/[table이름]?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
spring.datasource.username=[설정한유저네임(기본 root)]
spring.datasource.password=[설정한비밀번호]
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
logging.level.org.springframework.jdbc.core=TRACE
logging.level.org.springframework.jdbc.core
속성 : 실행되는 쿼리 문구를 로그에 보여준다.
✅@DirtiesContext
스프링 테스트에서 애플리케이션 컨텍스트는 딱 한 개만 만들어지고 모든 테스트에서 공유해서 사용한다.(이를 Context Caching이라고 함)
애플리케이션 컨텍스트의 구성이나 상태를 테스트 내에서 변경해야 하는 경우, 여러 테스트에서 해당 컨텍스트를 일관성 있게 사용하는 것이 어려워진다.
테스트 클래스/메서드에 @DirtiesContext
어노테이션을 추가하면 해당 테스트를 수행하기 전/후에 Context를 새로 생성하게 할 수 있다.
- 클래스에 붙여주는 경우
- 클래스의 테스트가 모두 끝난 후 context를 재생성한다(default)
@DirtiesContext public class ContextDirtyingTests { // 테스트 케이스가 context의 @Bean의 상태에 영향을 끼침 }
- 클래스의 테스트가 시작하기 전에 context 재생성
@DirtiesContext(classMode = BEFORE_CLASS)
- 클래스의 매 테스트가 시작하기 전에 context 재생성
@DirtiesContext(classMode = BEFORE_EACH_TEST_METHOD)
- 클래스의 매 테스트가 끝날 때마다 context 재생성
@DirtiesContext(classMode = AFTER_EACH_TEST_METHOD)
- 클래스의 테스트가 모두 끝난 후 context를 재생성한다(default)
- 메서드에 붙여주는 경우
- 특정 테스트 케이스(메서드)를 시작하기 전에 context 재생성
@DirtiesContext(methodMode = BEFORE_METHOD) @Test public void testProcessWhichRequiresFreshAppCtx() { // 새로운 context가 필요한 어떤 로직 }
- 특정 테스트 케이스를 시작한 이후 context 재생성
@DirtiesContext @Test public void testProcessWhichDirtiesAppCtx() { // context의 상태를 변경하는 어떤 로직 }
- 특정 테스트 케이스(메서드)를 시작하기 전에 context 재생성